import type { Context } from "~/types.js";
import type { CmsModel } from "@webiny/api-headless-cms/types/index.js";
import type { IWorkflowStateTransformer } from "~/context/transformer/abstractions/WorkflowStateTransformer.js";
import type { IWorkflowStateModel, IWorkflowStateRecord } from "./abstractions/WorkflowState.js";
import { WorkflowStateRecordState } from "./abstractions/WorkflowState.js";
import { WorkflowState } from "./workflowState/WorkflowState.js";
import type {
    IWorkflowStateContext,
    IWorkflowStateContextListStatesParams,
    IWorkflowStateContextListStatesResponse,
    IWorkflowStateContextOnStateAfterCreate,
    IWorkflowStateContextOnStateAfterDelete,
    IWorkflowStateContextOnStateAfterUpdate
} from "./abstractions/WorkflowStateContext.js";
import { WebinyError } from "@webiny/error";
import { parseIdentifier } from "@webiny/utils";
import { NotFoundError } from "@webiny/handler-graphql";
import { createTopic } from "@webiny/pubsub";
import {
    ActiveStateExistsError,
    MultipleWorkflowsFoundError,
    WorkflowNotFoundError,
    WorkflowsNotFoundError,
    WorkflowStateNotFoundError
} from "~/context/errors/index.js";
import type { IWorkflowStepTeam } from "~/context/abstractions/Workflow.js";

export interface IWorkflowStateContextParams {
    context: Pick<Context, "cms" | "security" | "workflows" | "workflowState" | "adminUsers">;
    model: CmsModel;
    transformer: IWorkflowStateTransformer;
}

type ICreateWorkflowStateEntryInput = Omit<
    IWorkflowStateRecord,
    "id" | "savedBy" | "createdOn" | "savedOn" | "createdBy"
>;

type IUpdateWorkflowStateEntryInput = Omit<
    IWorkflowStateRecord,
    "id" | "savedBy" | "savedOn" | "createdOn" | "createdBy"
>;

interface ICreateWorkflowStateParams {
    record: IWorkflowStateRecord;
}

export class WorkflowStateContext implements IWorkflowStateContext {
    private readonly context;
    private readonly model;
    private readonly transformer;
    public readonly onStateAfterCreate;
    public readonly onStateAfterUpdate;
    public readonly onStateAfterDelete;
    private readonly _userTeamsCache: Record<string, IWorkflowStepTeam[]> = {};

    public constructor(params: IWorkflowStateContextParams) {
        this.context = params.context;
        this.model = params.model;
        this.transformer = params.transformer;

        this.onStateAfterCreate = createTopic<IWorkflowStateContextOnStateAfterCreate>();
        this.onStateAfterUpdate = createTopic<IWorkflowStateContextOnStateAfterUpdate>();
        this.onStateAfterDelete = createTopic<IWorkflowStateContextOnStateAfterDelete>();
    }

    public async getState(id: string): Promise<IWorkflowStateModel> {
        const record = await this.fetchOne(id);
        if (!record) {
            throw new WorkflowStateNotFoundError({
                data: {
                    id
                }
            });
        }

        return this.createWorkflowState({
            record
        });
    }

    public async getTargetState(
        app: string,
        targetRevisionId: string
    ): Promise<IWorkflowStateModel> {
        const { version } = parseIdentifier(targetRevisionId);
        if (!version) {
            throw new WebinyError(
                "Cannot get a workflow state without version of a target record.",
                "VERSION_REQUIRED",
                {
                    app,
                    targetRevisionId,
                    version
                }
            );
        }

        const state = await this.fetchOneByTargetRevisionId(app, targetRevisionId);
        if (!state) {
            throw new WorkflowStateNotFoundError({
                data: {
                    app,
                    targetRevisionId
                }
            });
        }

        return this.createWorkflowState({
            record: state
        });
    }

    public async listStates(
        params?: IWorkflowStateContextListStatesParams
    ): Promise<IWorkflowStateContextListStatesResponse> {
        const id = this.context.security.getIdentity().id;
        /**
         * Let's prefetch user teams for the current identity to avoid multiple calls later.
         */
        await this.getUserTeams(id);

        const { items: states, meta } = await this.fetchAll(params);

        /**
         * Convert records to workflow state instances.
         */
        const items = await Promise.all(
            states.map(async record => this.createWorkflowState({ record }))
        );

        return {
            items,
            meta
        };
    }

    public async listOwnWorkflowStates(
        params?: IWorkflowStateContextListStatesParams
    ): Promise<IWorkflowStateContextListStatesResponse> {
        const identity = this.context.security.getIdentity();
        if (!identity?.id) {
            return {
                items: [],
                meta: {
                    cursor: null,
                    hasMoreItems: false,
                    totalCount: 0
                }
            };
        }

        return await this.listStates({
            ...params,
            where: {
                ...params?.where,
                isActive: true,
                createdBy: identity.id
            }
        });
    }

    public async listRequestedWorkflowStates(
        params?: IWorkflowStateContextListStatesParams
    ): Promise<IWorkflowStateContextListStatesResponse> {
        const identity = this.context.security.getIdentity();
        const teams = await this.getUserTeams(identity.id);
        if (!identity?.id || teams.length === 0) {
            return {
                items: [],
                meta: {
                    cursor: null,
                    hasMoreItems: false,
                    totalCount: 0
                }
            };
        }

        return await this.listStates({
            ...params,
            where: {
                ...params?.where,
                isActive: true,
                createdBy_not: identity.id,
                steps: {
                    teams: {
                        id_in: teams.map(team => team.id)
                    }
                }
            }
        });
    }

    public async createState(
        app: string,
        targetRevisionId: string,
        title: string
    ): Promise<IWorkflowStateModel> {
        const { id: targetId, version } = parseIdentifier(targetRevisionId);
        if (!version) {
            throw new WebinyError(
                "Cannot create a workflow state without version of a target record.",
                "VERSION_REQUIRED",
                {
                    app,
                    targetRevisionId,
                    version
                }
            );
        }
        /**
         * It may be possible, at some point, to have multiple workflows for a single app.
         * We will need to find a way to select a workflow for the given target record.
         * Until then, we will throw an error if multiple workflows are found for the given app.
         */
        const { items: workflows, meta } = await this.context.workflows.listWorkflows({
            where: {
                app
            },
            limit: 1
        });
        const workflow = workflows[0];
        if (!workflow) {
            throw new WorkflowsNotFoundError({
                data: {
                    app
                }
            });
        } else if (meta.totalCount > 1) {
            throw new MultipleWorkflowsFoundError({
                data: {
                    app,
                    targetRevisionId,
                    workflows,
                    meta
                }
            });
        }
        /**
         * We allow multiple states for the same target revision ID, but we cannot create a new one if there is an active state.
         */
        const existingState = await this.fetchOneByTargetRevisionId(app, targetRevisionId);
        if (existingState) {
            throw new ActiveStateExistsError({
                data: {
                    app,
                    targetRevisionId,
                    existingState
                }
            });
        } else if (workflow.steps.length === 0) {
            throw new WebinyError(
                "Cannot create a workflow state for a workflow that has no steps defined.",
                "WORKFLOW_NO_STEPS",
                {
                    app,
                    workflowId: workflow.id,
                    targetRevisionId
                }
            );
        }

        /**
         * Create a new workflow state entry.
         */
        const entry: ICreateWorkflowStateEntryInput = {
            workflowId: workflow.id,
            comment: undefined,
            state: WorkflowStateRecordState.pending,
            app,
            title,
            targetId,
            isActive: true,
            targetRevisionId,
            steps: workflow.steps.map(step => {
                return {
                    ...step,
                    state: WorkflowStateRecordState.pending,
                    savedBy: null,
                    comment: null
                };
            })
        };

        return this.context.security.withoutAuthorization(async () => {
            const result = await this.context.cms.createEntry<ICreateWorkflowStateEntryInput>(
                this.model,
                entry
            );
            const record = this.transformer.fromCmsEntry(result);
            const state = await this.createWorkflowState({
                record
            });

            try {
                await this.onStateAfterCreate.publish({
                    state
                });
            } catch (ex) {
                console.error(ex);
                // do nothing?
            }
            return state;
        });
    }

    public async updateState(
        id: string,
        input: Partial<Omit<IWorkflowStateRecord, "id">>
    ): Promise<IWorkflowStateModel> {
        const originalRecord = await this.fetchOne(id);
        if (!originalRecord) {
            throw new WorkflowStateNotFoundError({
                data: {
                    id,
                    input
                }
            });
        }
        // @ts-expect-error
        delete originalRecord["id"];

        const entryValues = this.transformer.toCmsEntry({
            ...originalRecord,
            ...input
        });

        const workflow = await this.context.workflows.getWorkflow({
            app: originalRecord.app,
            id: originalRecord.workflowId
        });
        if (!workflow) {
            throw new WorkflowNotFoundError(originalRecord.workflowId);
        }
        const originalState = await this.createWorkflowState({
            record: originalRecord
        });

        return await this.context.security.withoutAuthorization(async () => {
            const result = await this.context.cms.updateEntry<IUpdateWorkflowStateEntryInput>(
                this.model,
                id,
                entryValues
            );
            const record = this.transformer.fromCmsEntry(result);

            const state = await this.createWorkflowState({
                record
            });

            try {
                await this.onStateAfterUpdate.publish({
                    state,
                    original: originalState
                });
            } catch (ex) {
                console.error(ex);
                // do nothing?
            }

            return state;
        });
    }

    public async cancelState(id: string): Promise<IWorkflowStateModel> {
        return await this.updateState(id, {
            isActive: false
        });
    }

    public async deleteState(id: string): Promise<void> {
        const record = await this.fetchOne(id);
        if (!record) {
            throw new NotFoundError();
        }
        try {
            await this.context.security.withoutAuthorization(async () => {
                await this.context.cms.deleteEntry(this.model, id);
            });
        } catch (ex) {
            if (ex.code === "NOT_FOUND" || ex instanceof NotFoundError) {
                return;
            }
            throw ex;
        }
    }

    public async deleteTargetState(app: string, targetRevisionId: string): Promise<void> {
        const record = await this.fetchOneByTargetRevisionId(app, targetRevisionId);
        if (!record) {
            return;
        }
        try {
            await this.context.security.withoutAuthorization(async () => {
                await this.context.cms.deleteEntry(this.model, record.id);
            });
        } catch (ex) {
            if (ex.code === "NOT_FOUND" || ex instanceof NotFoundError) {
                return;
            }
            throw ex;
        }

        const workflow = await this.context.workflows.getWorkflow({
            app: record.app,
            id: record.workflowId
        });
        if (!workflow) {
            throw new WorkflowNotFoundError(record.workflowId);
        }

        const state = await this.createWorkflowState({
            record
        });

        try {
            await this.onStateAfterDelete.publish({
                state
            });
        } catch (ex) {
            console.error(ex);
            // do nothing?
        }
    }

    public async startStateStep(id: string): Promise<IWorkflowStateModel> {
        const state = await this.getState(id);
        await state.start();
        return state;
    }

    public async approveStateStep(id: string, comment?: string): Promise<IWorkflowStateModel> {
        const state = await this.getState(id);
        await state.approve(comment);
        return state;
    }

    public async rejectStateStep(id: string, comment: string): Promise<IWorkflowStateModel> {
        const state = await this.getState(id);
        await state.reject(comment);
        return state;
    }

    public async takeOverStateStep(id: string): Promise<IWorkflowStateModel> {
        const state = await this.getState(id);
        await state.takeOver();
        return state;
    }

    private async fetchOneByTargetRevisionId(
        app: string,
        targetRevisionId: string
    ): Promise<IWorkflowStateRecord | null> {
        const { items, meta } = await this.fetchAll({
            where: {
                app,
                targetRevisionId,
                isActive: true
            },
            limit: 1
        });
        /**
         * There cannot be more than one workflow. If there is, something is very wrong and all states, except one, must be deleted.
         */
        if (items.length === 0) {
            return null;
        } else if (meta.totalCount > 1) {
            throw new MultipleWorkflowsFoundError({
                data: {
                    app,
                    targetRevisionId,
                    items
                }
            });
        }
        return items[0] || null;
    }

    private async fetchOne(id: string) {
        try {
            const entry = await this.context.security.withoutAuthorization(async () => {
                return this.context.cms.getEntryById<Omit<IWorkflowStateRecord, "id">>(
                    this.model,
                    id
                );
            });
            return this.transformer.fromCmsEntry(entry);
        } catch (ex) {
            if (ex.code === "NOT_FOUND" || ex instanceof NotFoundError) {
                return null;
            }
            throw ex;
        }
    }

    private async fetchAll(params?: IWorkflowStateContextListStatesParams) {
        const [items, meta] = await this.context.security.withoutAuthorization(async () => {
            return await this.context.cms.listLatestEntries<Omit<IWorkflowStateRecord, "id">>(
                this.model,
                {
                    limit: 50,
                    sort: ["createdOn_DESC"],
                    ...params,
                    where: {
                        ...params?.where
                    }
                }
            );
        });
        const records = items.map(item => this.transformer.fromCmsEntry(item));
        return {
            items: records,
            meta
        };
    }

    private async createWorkflowState(
        params: ICreateWorkflowStateParams
    ): Promise<IWorkflowStateModel> {
        const id = this.context.security.getIdentity().id;
        return new WorkflowState({
            context: this.context,
            record: params.record,
            teams: await this.getUserTeams(id)
        });
    }

    private async getUserTeams(id: string): Promise<IWorkflowStepTeam[]> {
        if (!this._userTeamsCache[id]) {
            const teams = await this.context.adminUsers.listUserTeams(id);
            return (this._userTeamsCache[id] = teams.map(team => {
                return {
                    id: team.id
                };
            }));
        }
        return this._userTeamsCache[id];
    }
}
